/**
 * Copyright 2012 ibiblio
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.ibiblio.terasaur.user

import org.codehaus.groovy.grails.plugins.springsecurity.SpringSecurityUtils
import org.codehaus.groovy.grails.plugins.springsecurity.openid.OpenIdAuthenticationFailureHandler as OIAFH

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.savedrequest.DefaultSavedRequest
import org.slf4j.LoggerFactory

import org.ibiblio.terasaur.user.User
import org.ibiblio.terasaur.user.Role
import org.ibiblio.terasaur.user.UserRole
import org.ibiblio.terasaur.user.PasswordGenerator
import grails.plugins.springsecurity.Secured

/**
 * Manages associating OpenIDs with application users, both by creating a new local user
 * associated with an OpenID and also by associating a new OpenID to an existing account.
 */
class OpenIdController {

    /** Dependency injection for daoAuthenticationProvider. */
    def daoAuthenticationProvider

    /** Dependency injection for OpenIDAuthenticationFilter. */
    def openIDAuthenticationFilter

    /** Dependency injection for the springSecurityService. */
    def springSecurityService

    static defaultAction = 'auth'

    private static def log = LoggerFactory.getLogger(this)

    def _flushTransactions = false

    /**
     * Shows the login page. The user has the choice between using an OpenID and a username
     * and password for a local account. If an OpenID authentication is successful but there
     * is no corresponding local account, they'll be redirected to createAccount to create
     * a new account, or click through to linkAccount to associate the OpenID with an
     * existing local account.
     */
    def auth = {
        Map result = _auth()
        if (result["redirect"]) {
            redirect(uri:result["redirect"])
            return
        } else {
            return result
        }
    }

    Map _auth() {
        def config = SpringSecurityUtils.securityConfig
        def result

        if (springSecurityService.isLoggedIn()) {
            result = ["redirect": config.successHandler.defaultTargetUrl]
        } else {
            result = [
                openIdPostUrl: "${request.contextPath}$openIDAuthenticationFilter.filterProcessesUrl",
                daoPostUrl: "${request.contextPath}${config.apf.filterProcessesUrl}",
                persistentRememberMe: config.rememberMe.persistent,
                rememberMeParameter: config.rememberMe.parameter,
                openidIdentifier: config.openid.claimedIdentityFieldName
                ]
        }
        return result
    }

    /**
     * Initially we're redirected here after a UserNotFoundException with a valid OpenID
     * authentication. This action is specified by the openid.registration.createAccountUri
     * attribute.
     * <p/>
     * The GSP displays the OpenID that was received by the external provider and keeps it
     * in the session rather than passing it between submits so the user has no opportunity
     * to change it.
     */
    def createAccount = { OpenIdRegisterCommand command ->
        String openId = session[OIAFH.LAST_OPENID_USERNAME]
        if (!openId) {
            flash.error = 'Sorry, an OpenID was not found'
            return [command: command]
        }

        if (!request.post) {
            // show the form
            command.clearErrors()
            copyFromAttributeExchange command
            return [command: command, openId: openId]
        }

        if (command.hasErrors()) {
            return [command: command, openId: openId]
        }

        if (!createNewAccount(command.username, command.password, openId)) {
            return [command: command, openId: openId]
        }

        authenticateAndRedirect command.username
    }

    /**
     * This linkAccount action is different from the original plugin version.  Ours
     * allows an admin to attach a free-form openid URL to an existing account.
     */
    @Secured(['ROLE_ADMIN', 'IS_AUTHENTICATED_REMEMBERED'])
    def linkAccount = { OpenIdLinkAccountCommand command ->
        def doRedirect = false
        Map model = [
            command: command,
            user: null,
            openId: null
            ]
        if (command.openIdUrl) {
            if (command.hasErrors()) {
                _handleCommandErrors(command)
            } else {
                def result = _linkAccount_save(command)
                doRedirect = result.doRedirect
                model.openId = result.openId
                model.user = result.user
            }
        }
        if (!doRedirect) {
            return model
        }
    }

    private Map _linkAccount_save(command) {
        Map result = [
            user: null,
            openId: null,
            doRedirect: false
            ]
        result.user = User.get(command.userId)
        if (result.user) {
            result.openId = new OpenId(url:command.openIdUrl)
            result.user.openIds << result.openId
            if (result.user.save()) {
                flash.message = 'OpenID added'
                redirect(controller:'user', action:'edit', id:result.user.id)
                result.doRedirect = true
            } else {
                flash.message = 'Oops, there was a problem adding the OpenID'
            }
        } else {
            flash.message = 'Could not find user for id ' + command.userId
        }
        return result
    }

    private void _handleCommandErrors(command) {
        // Check for missing trailing slash
        if (command.errors.hasFieldErrors('openIdUrl')) {
            def errors = command.errors.getFieldErrors('openIdUrl')
            def matchesError = false
            errors.each {
                if (it.code == "matches.invalid") {
                    matchesError = true
                }
            }
            if (matchesError) {
                flash.message = 'OpenID URLs must have a trailing slash'
            }
        } else {
            flash.message = 'Uh oh, there was a problem with the link account input'
        }
    }

    /**
     * Authenticate the user for real now that the account exists/is linked and redirect
     * to the originally-requested uri if there's a SavedRequest.
     *
     * @param username the user's login name
     */
    private void authenticateAndRedirect(String username) {
        session.removeAttribute OIAFH.LAST_OPENID_USERNAME
        session.removeAttribute OIAFH.LAST_OPENID_ATTRIBUTES

        springSecurityService.reauthenticate username

        def config = SpringSecurityUtils.securityConfig

        def savedRequest = session[DefaultSavedRequest.SPRING_SECURITY_SAVED_REQUEST_KEY]
        if (savedRequest && !config.successHandler.alwaysUseDefault) {
            redirect url: savedRequest.redirectUrl
        }
        else {
            redirect uri: config.successHandler.defaultTargetUrl
        }
    }

    /**
     * Create the user instance and grant any roles that are specified in the config
     * for new users.
     * @param username  the username
     * @param password  the password
     * @param openId  the associated OpenID
     * @return  true if successful
     */
    private boolean createNewAccount(String username, String password, String openId) {
        boolean created = User.withTransaction { status ->
            _createNewAccount_transaction(username, password, openId)
        }
        return created
    }

    private boolean _createNewAccount_transaction(String username, String password, String openId) {
        def config = SpringSecurityUtils.securityConfig
        def user = new User(username: username, password: password, enabled: true)
        user.addToOpenIds(url: openId)

        if (!user.save(flush: _flushTransactions)) {
            return false
        }

        for (roleName in config.openid.registration.roleNames) {
            UserRole.create(user, Role.findByAuthority(roleName), _flushTransactions)
        }
        return true
    }

    /**
     * Associates an OpenID with an existing account. Needs the user's password to ensure
     * that the user owns that account, and authenticates to verify before linking.
     * @param username  the username
     * @param password  the password
     * @param openId  the associated OpenID
     */
    private void registerAccountOpenId(String username, String password, String openId) {
        // check that the user exists, password is valid, etc. - doesn't actually log in or log out,
        // just checks that user exists, password is valid, account not locked, etc.
        daoAuthenticationProvider.authenticate(
                new UsernamePasswordAuthenticationToken(username, password))

        User.withTransaction { status ->
            _registerAccountOpenId_transaction(username, openId)
        }
    }

    private void _registerAccountOpenId_transaction(String username, String openId) {
        def user = User.findByUsername(username)
        user.addToOpenIds(url: openId)
        if (!user.validate()) {
            status.setRollbackOnly()
        }
    }

    /**
     * For the initial form display, copy any registered AX values into the command.
     * @param command  the command
     */
    private void copyFromAttributeExchange(OpenIdRegisterCommand command) {
        List attributes = session[OIAFH.LAST_OPENID_ATTRIBUTES] ?: []
        for (attribute in attributes) {
            // TODO document
            String name = attribute.name
            if (command.hasProperty(name)) {
                command."$name" = attribute.values[0]
            }
        }
    }
}

class OpenIdRegisterCommand {
    String username = ""
    PasswordGenerator pwg = new PasswordGenerator()
    String password = pwg.getRandom(20)

    static constraints = {
        username blank: false, validator: { String username, command ->
            User.withNewSession { session ->
                if (username && User.countByUsername(username)) {
                    return 'openIdRegisterCommand.username.error.unique'
                }
            }
        }
    }
}

class OpenIdLinkAccountCommand {
    String userId = ""
    String openIdUrl = ""
    String username = ""

    static constraints = {
        userId blank: false
        // openID urls must have a trailing slash
        openIdUrl blank: false, matches: /.*\/$/
    }
}
